Skip to content

Oura integration - Fixing Time anchoring, Sleep session from the ring, Framing / decode robustness, Activity (Tier-B, deliberately not persisted)#143

Open
pipiche38 wants to merge 25 commits into
ryanbr:mainfrom
pipiche38:oura-integration-clean

Conversation

@pipiche38

@pipiche38 pipiche38 commented Jul 9, 2026

Copy link
Copy Markdown

What this PR does

Summary

Brings the Oura Ring Gen3 into NOOP as a first-class, honest-data source over its native BLE
protocol — live HR/IBI, HRV, SpO2, skin temperature, and a ring-native sleep session — while keeping
the Tier-A/Tier-B trust invariant and byte-for-byte Swift↔Kotlin parity. The headline fix (#91)
stops a newly-paired ring from re-dumping its entire banked history on every connect.

Merged up to main @ v8.5.2; the only conflict (IntelligenceEngine.swift) was a clean
both-sides-added resolution (the Oura sleep bridge alongside main's #141 HRV nightly trace).

What this adds

Time anchoring (the core of honest Oura data)

  • Send SyncTime (0x12) on connect (authoritative u64-LE-seconds + tz) to unlock the ring→UTC anchor.
  • Ring-time→UTC anchor from 0x42 time-sync (primary) / 0x85 RTC beacon (secondary), gated to a
    plausible 2020–2035 epoch and a plausible boot-relative ring-time (≤500M ticks). Neither half
    alone is enough — a misframed record can carry a good epoch with a garbage ring-time.
  • Anchor-relative phantom guard: a history sample converting to >+1 day or <−90 days from the session
    anchor is dropped, never banked at a wrong wall-clock time.

#91 — the full re-dump loop (headline)

  • The GetEvents (0x11) response was misread: bytes 2–5 are bytes_left (a remaining-BYTE count that
    drains ~800 KB→0), not a "last ring-timestamp cursor". Persisting that byte-count and comparing it
    across sessions minted a phantom "ring-time regression" every connect → reset-to-0 → a full re-dump
    (~40 k phantom drops + log spam).
  • Fixed to open_oura's model: drain while bytes_left > 0; the resume position is client-managed
    (max stored ring-time), only persisted once it resolves against the anchor. A plausibility ceiling
    clamps any absurd persisted cursor back to a full pull, so a pre-fix garbage cursor can't wedge the
    ring on a seek-past-end forever. Cursor renders as a human-readable date in logs.
  • Proven on-device (Gen3): after one clamped full pull, connect-2/3 resume from a real cursor and
    return only new events with bytes_left=0 — zero phantom drops, no re-dump.

Sleep (ring-native, honest)

  • Build a NOOP SleepSession from the ring's own check_sleep window (s:/e: firmware bedtime→wake,
    validated 7 h 56 m vs a wearer's ~7 h 52 m) — preferred over the sparse connection-time
    OURA_SLEEP_PHASE bursts, which under-count. Stage-unknown but honest total sleep; nothing faked.
  • Surfaced through the SAME analytics funnels as WHOOP (providedSleepSessions), so the ring's night
    opens the skin-temp window and feeds rest — without hiding a richer WHOOP import for the same day.

Framing / decode robustness

  • .debugText (0x43) gets its own ingest case (was silently dropped): logs each diagnostic string, and
    as a bonus a debug string can no longer alias a real event tag on that path. Gated against high-rate
    firmware chatter with a drop-prefix list + consecutive-duplicate suppression.
  • Resync the TLV reassembler on an implausible record type (no SOF marker → don't emit garbage, don't wedge).
  • Gate SpO2 persistence to a plausible [85,100] %.

#87 Activity (Tier-B, deliberately not persisted)

  • Surface an Oura activity/step proxy by promoting the Tier-B MET stream for logging/investigation only
    — logged as a PROXY, never turned into stored steps (MET ≠ steps; honest-data invariant).

Docs

  • docs/OURA_PROTOCOL.md: digest of the open_oura crates/tools (§9), the two-half anchor-set gate (§5.5),
    the bytes_left correction (§5.2/§5.3), check_sleep as the honest sleep source (§6.15), and the
    SleepNet-is-cloud-locked note (§6.12.1).

Testing

Package Result
OuraProtocol 102 / 0
StrandAnalytics 978 / 0
WhoopStore 235 / 0

All new decode/anchor/framing behavior is unit-tested in the pure packages; the Android twin mirrors
every fixture byte-for-byte.

Cross-platform parity

Every protocol/mapping change lands on both the Swift package and the Kotlin twin (android/), with
identical test fixtures — OuraDriver, Framing, OuraLiveSource, the sleep bridge, and the anchor
gate all match across platforms.


Reserves / caveats (known limitations, read before merge)

  1. App target + Android not compiled in the authoring env. The three pure packages build and pass
    (102/978/235), and the merge-conflict resolution is structurally verified, but a full
    xcodebuild -scheme NOOPiOS and the Gradle build are the last gate not run here. Build locally
    before merging.
  2. check_sleep persistence is a PROTOTYPE. The window is decoded, anchored, logged, and bridged
    into analytics, but promoting it to a durable sleepSession row is still stabilizing — on a day
    where a richer WHOOP import exists, the import intentionally still wins ("richer record wins").
    A pure-Oura day is the case that surfaces the ring's own night.
  3. The resume cursor holds (does not advance) on an anchorless reconnect — by design. The anchor is
    dump-borne (it rides inside the history stream), so a quiet reconnect with little new history may
    carry no anchor; the cursor then stays put and re-pulls the same small sliver next time rather than
    advancing past un-banked data. Self-healing: a larger sync eventually carries an anchor and the cursor
    jumps forward. It cannot regress to a 40 k full dump (that only happened from cursor 0).
  4. Steps remain Tier-B / proxy-only. The real step source (0x7E/0x7F real_steps) is
    server-flag-gated OFF on the ring, so the Steps card staying empty is expected; the MET-derived
    figure is logged as a proxy, never stored.
  5. Single-generation data point. All wire findings are from one Oura Ring Gen3. The 0x42
    seconds-not-ms unit correction and the anchor windows should be treated as Gen3-validated until a
    second ring/generation confirms them.
  6. SourceKit noise. App-target files show "No such module 'WhoopProtocol'" diagnostics in this env —
    a known false positive (the app target can't resolve SPM modules outside Xcode), not a real error.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Test environment is MacOs. I'm rebuilding the all application and running it. Comparing Oura ring Gen 3 collected data against Whoop/MG (connected to Whoop App)

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

pipiche38 and others added 16 commits July 8, 2026 11:39
  The 0x43 debug spill wasn't documented, so I added it in three linked spots:
- §2.4 (reassembly) — a dated "byte-alignment desync → phantom tags" note: how a run of 0x43 debug records surfaced as a phantom 0x4C "sleep_summary" because 0x4C=L/0x49=I/0x53=S are legal ASCII chars that alias real tags, plus the defensive resync rule (reject len<4, resynchronise on implausible type/len, never let a mid-stream byte mint a Tier-B summary).
- §6.15 (0x43) — expanded with the actual decoded strings ("Sw to App", "in_bed=0", "…in_info=6", "bc 0x43"), the TLV layout, and the aliasing caution.
- §6.12 (sleep summaries) — a "beware phantom sightings" warning so nobody chases an ASCII 0x4C as a real fixture.
Gave .debugText its own ingest case (it previously fell into default and was dropped). It now logs each non-empty, trimmed diagnostic string with its ring counter:
  Oura: debug (0x43) rt=60470 "in_bed=0"
  Diagnostics only — never persisted or scored. As a bonus, decoding these here also means a debug string can no longer silently alias a real event tag on that code path.
…wo mechanisms:

1. Drop-prefix list for the known high-rate firmware chatter:
  DHR_mode, DHR data sub, DHR_state, DHR_info,   ← ~150ms HR state-machine spam
  PPG_cont, S:, E:, AFs, blestda, nr, BQ         ← per-beat PPG windows / AGC / BLE state
  1. Case-sensitive on purpose: the uppercase S:/E: PPG markers are dropped, while the lowercase s:/e: sleep boundaries survive.
  2. Consecutive-duplicate suppression — collapses runs of identical lines (e.g. the DHR_info:neg t ×20 burst) via a lastDebugText guard.
The .streaming block now hands the ring the current UTC (OuraCommands.syncTime)
BEFORE draining history, so it emits a usable 0x42 time-sync event (§5.5).
Without this every fetched record stayed "[no anchor yet]" and last-night
sleep / skin-temp could not be placed on a calendar day (Sleep screen read
matched=0). SyncTime writes the ring clock, exactly as the official Oura app
does on every connect. Sent once per session, before the first fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OuraReassembler.feed only validated len >= 4, never the type byte. Once
byte-misaligned inside a 0x43 debug-text payload, ASCII letters that alias real
tags (p=0x70, I=0x49, L=0x4C, Q=0x51) minted a PHANTOM "summary" whose bogus len
swallowed the following real records (e.g. the sleep-phase timeline). Now a
record only starts on a plausible type - a known OuraEventTag or the 0x11/0x0D
outer responses that round-trip - else drop one byte and re-scan. Adds
isPlausibleRecordStart + 3 FramingTests; realigns instead of stalling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reassembler-misaligned phantom landing on a known spo2 tag byte decoded to
impossible values and got persisted (integration build: 159 spo2Sample rows,
-320..12,856,474), violating the honest-data invariant on a ring whose SpO2
feature 0x04 is gated OFF.

OuraStreamMapping now persists an .spo2 sample only when its value is in the
plausible oxygen-saturation band [85,100], aligned with open_oura tools/run_spo2.py
(r-ratio calibration clamped to [85,100]) and Oura's own reporting floor. Values
outside are DROPPED, never clamped - clamping garbage to "100%" would fabricate an
oxygen reading. Only 0x6F yields direct percentages (~95-96, s6.5); 0x7B (unpinned
uint16) and 0x77 dc_raw (PPG waveform) are not percentages, and nothing downstream
reads spo2 red (AnalyticsEngine nulls spo2Pct), so this loses no real signal and
yields zero rows on the gated-off ring. Mirrored in the Kotlin twin for parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…§6.12.1)

   Investigated open_oura docs/algorithms/sleepnet.md. The Oura app's 4-stage
   hypnogram is NOT on the BLE wire: it is SleepNet, an on-PHONE PyTorch model
   (per-30s epochs) shipped AES-256-GCM-encrypted with a server-delivered,
   cloud-cached key — the one Oura metric not reproducible without Oura's cloud.

   Documents the consequence for NOOP's honest-data invariant: we do not chase
   SleepNet. A NOOP sleep session is built from signals that cross the BLE boundary
   — the ring's Tier-A 2-bit 0x4E/0x5A phase codes (OURA_SLEEP_PHASE), its 0x49/0x76
   boundaries and 0x43 debug in_bed/s:/e: narration, plus NOOP's own staging over
   raw HR/HRV/temp/motion — never relabelled as "Oura sleep stages". Adds a
   [sleepnet] citation key.
A framing desync mints a record with a garbage ring timestamp that, at 100 ms/tick,
converts to a date days-to-years from the anchor yet still inside the loose
2020-2035 epoch gate (rt~1.9e9 -> +6yr/2033; rt~16.7M -> +19 days). On live Gen 3
this scattered ~25% of a night's skin-temp rows across 2020-2034, so they never
landed on the correct calendar day - and the same phantoms carried the garbage
SpO2 values.

OuraDriver.unixSeconds now rejects any conversion more than +1 day after or
-90 days before the session anchor (history is always recent-past relative to the
0x42 anchor). Returns nil so the caller drops/parks the sample instead of banking a
mis-dated row (honest-data invariant) - the single chokepoint every history sample
passes. Mirrored in the Kotlin twin; +4 tests (Swift+Kotlin), OURA_PROTOCOL.md 5.5
documents the guard. 83 OuraProtocol tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AnalyticsEngine derives sleep from the gravity-driven SleepStager, and an Oura ring
streams no accelerometer - so Sleep + Skin Temp stayed blank even with a night of
correctly-dated OURA_SLEEP_PHASE events. NOOP now builds its OWN session from the
ring's coarse phase codes (the polished hypnogram is SleepNet, cloud-gated, not on
the wire - OURA_PROTOCOL.md 6.12.1).

- OuraSleepSessionBuilder (pure): (ts, 2-bit stage) -> [SleepSession] with merged
  StageSegments + efficiency; splits on gaps, drops sub-minimum/all-wake runs.
- analyzeDay gains providedSleepSessions: when set it REPLACES the gravity detector,
  so the ring's night flows through the same funnels (sleep totals + skin-temp
  window). nil default keeps every WHOOP caller byte-identical.
- 11 tests incl. an end-to-end analyzeDay-with-no-gravity proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-dived Th0rgal/open_oura (Rust workspace RE'd from libringeventparser.so + the
Android app) and folded the findings in as section 9 (leads to fixture-validate,
per the doc's trust rule):
- canonical connect->sync sequence + the "no RData 0x03 on a normal pull" rule;
- authoritative opcode / 0x2F sub-op set; cursor = deciseconds; legacy vs extended;
- feature-gating mechanism (server flags default false client-side) explaining the
  SpO2 / real_steps OFF defaults;
- undocumented event tags (0x62 respiration spot, 0x8B spo2_r_pi, 0x71/0x6E IBI,
  0x59/0x56/0x6C/0x74/0x84/0x86, 0x87/0x88 backend-gated, 0x80 gen conflict);
- RData raw sampler (0x03) + the raw-accelerometer lead; batched timestamping;
- reference SpO2 quadratic + skin-temp algorithm.
Flags two actions in section 8: the SyncTime (0x12) send-layout mismatch and the
skinTempFunnel 300-sample floor vs open_oura's ~120.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ded a live body-weight provider from the profile down to the estimator, matching the codebase's existing closure-injection style:

AppModel.profile.weightKg
  → SourceCoordinator(bodyweightKg:)          [new param]
    → OuraLiveSource(bodyweightKg:)           [new param]
      → OuraActivityEstimator.summarize(bodyweightKg: bodyweightKg())

- Read live (a closure, not a captured value), so if you edit your weight in Settings it takes effect on the next flush.
- Removed the activityBodyweightKg = 75.0 constant entirely.
- Default fallback is 70 kg (only used if profile weight is unset/≤0) — deliberately matching the production calorie path's own 70 kg fallback, so there's one consistent default across the app rather than introducing a second (75).
 The old syncTime built a 24-bit floor(unix/256) coarse time (~4.3-min
 resolution) with a bogus 0xF6 trailer copied from the 0x85 RTC-beacon.
 This did not match the native client's req_sync_time(secs, 0) and could
 set the ring clock wrongly / be silently ignored (masked only by the
 ring's own RTC still yielding a ~correct 0x42 anchor).

 Now emit `12 09 <unix_secs: u64 LE (8 B)> <tz: i8 half-hours>`, tz=0
 (UTC) as the reference client sends; NOOP buckets local days downstream.
 Mirrored byte-for-byte in the Kotlin twin. Tests pin the byte layout and
 the signed tz byte on both sides. Doc §5.4/§8/§9.2 updated to FIXED.
 An Oura ring streams no accelerometer, so the gravity-driven SleepStager
 returns nothing for its nights: the Sleep card read blank AND the
 skin-temp funnel window (gated to a sleepSession) never opened.

 IntelligenceEngine now extracts the OURA_SLEEP_PHASE events from the
 night window's events (already read for the off-wrist backstop, no extra
 store read), builds sessions via OuraSleepSessionBuilder, and passes them
 to analyzeDay as providedSleepSessions. When non-nil these replace the
 gravity detector's output, so the ring's night flows through the SAME
 funnels the WHOOP path uses (sleep totals, skin-temp window, rest). Any
 non-Oura owner emits no such events, so the gravity stager stays
 byte-identical for every existing device. The built sessions land in
 res.cachedSleep, which the existing pipeline already persists.

 Kotlin twin: added providedSleepSessions to AnalyticsEngine.analyzeDay,
 the new OuraSleepSessionBuilder, and the IntelligenceEngine wiring
 (parsing `phase` from EventRow.payloadJSON). StrandAnalytics 964 tests
 pass, incl. the builder suite.
…lock

The phantom guard (unixSeconds anchor-relative window) already rejected a
misframed record's garbage ring timestamp on the ingest path. But the
pending-anchor DRAIN collapsed the two reasons unixSeconds returns nil --
"no anchor yet" and "anchor present but rt is phantom" -- into a single
'?? now` fallback. So a misframed skin-temp/SpO2/phase parked before the
anchor landed was stamped at wall-clock and BANKED, slipping past the
guard and polluting "today" with a bogus sample (honest-data violation).

Expose driver.hasAnchor so the drain can tell them apart: anchor present
+ rt rejected -> DROP (and log the count); no anchor this session ->
keep the honest wall-clock fallback so a real last-night record is
placed at ~arrival rather than silently lost.
…p source)

The decoded OURA_SLEEP_PHASE (0x4E) events turn out to be sparse bursts
the ring emits only at connection time, not a continuous overnight
timeline -- a real 8 h night persisted ~30 phase events clustered at the
sync moments, none during sleep, so a session built from them
under-counts (9.2 h read as ~5 h).

The ring's firmware instead narrates its OWN sleep decision as 0x43
debug text: `check_sleep`, then `s: <rt>` (bedtime) / `e: <rt>` (wake),
where <rt> is a ring timestamp in the 0x42 anchor domain -- so
unixSeconds(rt) converts each straight to UTC. OuraCheckSleepParser
(pure, both platforms) extracts the window and OuraLiveSource logs the
anchored bedtime->wake span. Validated on device: last night read
23:46 -> 07:43 (7h 56m) vs the wearer's actual 23:49 -> 07:41.

A lone `e:` can arrive with no fresh `s:`, pairing the new wake against
the previous night's stale start -- observed live as a phantom 33 h
window. An 18 h max-window guard rejects it (honest-data).

Also confirmed and documented (OURA_PROTOCOL.md 6.15): the Tier-B
sleep_summary tags (0x49/4B/4C/57/58 = ASCII I/K/L/W/X) captured so far
are framing-desync aliases of debug text ("DHR_state:2", "batt: 100"),
not real summaries -- do not decode until a clean binary fixture.

INVESTIGATION prototype: the window is LOGGED, not yet persisted as a
sleepSession.
@pipiche38 pipiche38 marked this pull request as draft July 9, 2026 08:11
pipiche38 and others added 5 commits July 9, 2026 10:28
   Promote the §6.15 check_sleep prototype to a real sleepSession. The
   ring's own bedtime->wake decision (validated on device against imported
   WHOOP reference: 476 min / 23:46->07:43 vs WHOOP 461-472 min /
   23:49->07:41 -- within ~4 min) is the reliable duration source where the
   sparse OURA_SLEEP_PHASE bursts under-count.

   - OuraLiveSource persists an OURA_SLEEP_WINDOW event (ts=bedtime,
     payload {start,end}) on a stable window; DO NOTHING upsert => one row
     per night.
   - OuraSleepSessionBuilder.session(fromWindowStart:end:) builds one
     session: a single stage-UNKNOWN "asleep" segment, efficiency 1.0.
   - hypnogramMetrics counts TST as any non-wake time -- byte-identical for
     every gravity/phase session (light|deep|rem are the only non-wake
     labels those stagers emit), and it lets the "asleep" window contribute
     its full duration to total sleep WITHOUT inventing deep/REM/light
     (those stay 0). Honest: real duration, blank stages.
   - IntelligenceEngine reads OURA_SLEEP_WINDOW and PREFERS it over the
     phase-event builder (gravity stager for non-Oura owners).

   Kotlin twin throughout. StrandAnalytics 973 / WhoopStore 234 tests pass.
   NOTE: surfacing is still gated -- the ring streams no overnight HR so
   IntelligenceEngine's hr>=200 night gate skips Oura nights; addressed
   next.
The persisted OURA_SLEEP_WINDOW never reached a card: an Oura ring banks
no overnight HR (disconnected while you sleep), and two HR-only gates
blocked it. Fix both — but rank a bare window BELOW any full record, so
the ring surfaces only nights nothing richer owns.

1. Day ownership. resolveDayOwner marked hasData from HR presence alone,
   so the active ring (priority 0) lost every night to an imported WHOOP
   strap (priority 2). It now also counts a persisted OURA_SLEEP_WINDOW —
   BUT as new DayOwnerResolver.Candidate.richData=false. A richer record
   (richData=true: HR-derived stages/recovery/HRV) wins before device
   priority, so a WHOOP import keeps a day it fully recorded (same
   duration, but stages+recovery preserved) and the ring's bare window
   only surfaces a day nothing richer owns. richData defaults true, so
   every legacy candidate collapses to the old priority-only order —
   existing days are byte-identical.
2. Scoring gate. The loop skipped any night with < 200 HR samples. It now
   also scores a night with an OURA_SLEEP_WINDOW (duration honest from the
   window; HR-derived fields stay null) — reached only on days the ring
   actually owns per (1).

New WhoopStore.hasEvent(deviceId:kind:from:to:) — a LIMIT-1 EXISTS probe
(no payload decode) drives both. Kotlin twin throughout (DayOwnerResolver
richData, WhoopDao.hasEvent + repo, IntelligenceEngine). WhoopStore 235 /
StrandAnalytics 976 tests pass (+3 resolver richness cases).

Validation: last connection logged the ring's Jul 7->8 check_sleep window
at 9h12m = 552 min, matching the imported WHOOP night's totalSleepMin=552
to the minute. That night WHOOP (richer) keeps the day; Jul 8->9 (7h56m,
no WHOOP import) the ring owns and surfaces.
…ume cursor

   The GetEvents (0x11) response was being misread: bytes 2-5 are bytes_left
   (a remaining-BYTE count that drains ~800KB→0), not a "last ring timestamp
   cursor". Persisting that byte-count and comparing it across sessions minted a
   phantom "ring-time regression" every connect → reset-to-0 → full history
   re-dump (~40k phantom-record drops + ryanbr#91 log spam).

   Fix, matching open_oura's model (EventBatchSummary + nextEventToSync):
   - parseGetEventsResponse now returns (eventsReceived, bytesLeft, moreData);
     the drain loops while bytes_left > 0, and the resume position is
     CLIENT-managed (max stored ring-time), not read from the 0x11 body.
   - OuraLiveSource persists a real ring-time resume cursor, only when it
     resolves against the anchor; a plausibility ceiling (maxPlausibleResumeTicks
     = 500M ds) clamps any absurd persisted value back to a full pull so a
     pre-fix garbage cursor can't wedge the ring on a seek-past-end forever.
   - describeCursor renders the cursor as a human-readable date (all history /
     date / out-of-window / no-anchor).

   Proven on-device (Oura Ring Gen3): connect 1 clamps the leftover garbage
   cursor, does one full pull, acquires the anchor, and stores resume cursor
   1624998 [2026-07-09 16:25:42]; connect 2 resumes from it and returns 55
   events with bytes_left=0 — zero phantom drops, no re-dump.

   Android twin + docs/OURA_PROTOCOL.md §5.2/§5.3 updated to match.
   OuraProtocol tests 99/0.
The anchor-set paths (0x42 time-sync, 0x85 RTC beacon) validated only the
epoch (2020-2035). A framing desync can hand an anchor record a plausible
epoch paired with a GARBAGE ring-time in the billions (the 4 rt bytes read
off a wrong offset). Anchoring on that pins anchorRingTime ~2e9 ticks from
every real sample, so the anchor-relative phantom guard then drops ALL
genuine history — the ring's data starves even though the epoch looked fine.

Add isPlausibleAnchorRingTime (<= 500M ticks ≈ 579 days uptime, mirroring
OuraLiveSource.maxPlausibleResumeTicks) and require BOTH halves before
seating an anchor, in both drivers. OuraLiveSource now mirrors the full gate
so "UTC time anchor acquired" never logs on a sync the driver rejected, and
names which half failed (epoch vs ring-time).

Tests: OuraProtocol 102/0 (+3 Swift: ring-time bounds, garbage-rt time-sync,
garbage-rt beacon; mirrored in the Kotlin twin). docs/OURA_PROTOCOL.md §5.5
documents the two-half anchor-set gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	Strand/Data/IntelligenceEngine.swift
@pipiche38 pipiche38 marked this pull request as ready for review July 9, 2026 14:53
@pipiche38

Copy link
Copy Markdown
Author

Just confirm the iOS Build Succeeded !

@pipiche38 pipiche38 changed the title Oura integration - WIP Oura integration - Fixing Time anchoring, Sleep session from the ring, Framing / decode robustness, Activity (Tier-B, deliberately not persisted) Jul 9, 2026
…finite loop)

   The GetEvents 0x11 drain re-dumps the ring's entire log from boot on every
   cursor-0 continuation, so when bytesLeft plateaus instead of reaching 0 the
   drain looped forever. Bound it three ways: a full-pull-pass fast-exit (stop
   after one complete pass once maxStoredRingTime resolves against the anchor),
   a stall guard (bytesLeft not advancing by drainProgressEpsilon), and a
   wall-clock deadline. On any forced stop we commit maxStoredRingTime if it
   resolves and otherwise HOLD the cursor — never reset to 0, which was what
   minted the re-dump. Verified on-device: stops after 2 reads, clean resume.
   A misframed 0x42/0x85 beacon carrying a wrong-year epoch (e.g. 2021) passed
   the static 15-year plausibility window and seated a bad ring→UTC anchor,
   mis-stamping whole sample batches into 2021 and starving recent history.
   Add acceptsAnchorEpoch(): still requires the 2020–2035 floor, but when a
   near-now reference is supplied the epoch must fall within maxAnchorDrift
   (7 days) of it. OuraLiveSource seeds the reference at driver-creation time.
   Falls back to the static window when no reference is set (test/replay).

   Verified on-device: 2021-dated beacons now rejected ("not within ±7d of
   now"); last night's samples stamp correctly. Kotlin twin kept in parity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant